#!/usr/bin/env python

# Genres: http://www.multimediasoft.com/amp3dj/help/amp3dj_00003e.htm
# MP3:    http://www.mpgedit.org/mpgedit/mpeg_format/mpeghdr.htm
# ID3v1:  http://en.wikipedia.org/wiki/Id3#Layout
# ID3v2:  http://www.id3.org/Developer_Information
# IFF:    http://en.wikipedia.org/wiki/Interchange_File_Format
# RIFF:   http://www.midi.org/about-midi/rp29spec(rmid).pdf
# MP4:    http://atomicparsley.sourceforge.net/mpeg-4files.html
# Vorbis: http://www.xiph.org/vorbis/doc/v-comment.html
# FLAC:   http://flac.sourceforge.net/format.html#stream
# OGG:    http://en.wikipedia.org/wiki/Ogg#File_format

from struct import Struct, error as StructError
from collections import MutableMapping
import sys
import os
import re

try:
    from cStringIO import StringIO
except ImportError:
    from StringIO import StringIO

try:
    from PIL import Image
    from PIL.ImageFile import ImageFile
    PIL = True
except ImportError:
    PIL = False

__version__ = '2.0'

SAMPLESIZE = 65536
GAPLESS = u'iTunPGAP'
ANY = -1
DEFAULT_ID3_VERSION = 2
DEFAULT_ID3V2_PADDING = 0
RVA2FACTOR = 10

DICT = 'DICT'
IDICT = 'IDICT'
TEXT = 'TEXT'
UINT16 = 'UINT16'
BOOL = 'BOOL'
UINT16X2 = 'UINT16X2'
GENRE = 'GENRE'
INT32 = 'INT32'
IMAGE = 'IMAGE'
UINT32 = 'UINT32'

TYPES = {'_comment': DICT,
         '_image': IDICT,
         '_lyrics': DICT,
         '_unknown': DICT,
         'album': TEXT,
         'album_artist': TEXT,
         'artist': TEXT,
         'bpm': UINT16,
         'comment': TEXT,
         'compilation': BOOL,
         'composer': TEXT,
         'disk': UINT16X2,
         'encoder': TEXT,
         'gapless': BOOL,
         'genre': GENRE,
         'grouping': TEXT,
         'image': IMAGE,
         'lyrics': TEXT,
         'name': TEXT,
         'sort_album': TEXT,
         'sort_album_artist': TEXT,
         'sort_artist': TEXT,
         'sort_composer': TEXT,
         'sort_name': TEXT,
         'sort_video_show': TEXT,
         'track': UINT16X2,
         'video_description': TEXT,
         'video_episode': UINT32,
         'video_episode_id': TEXT,
         'video_season': UINT32,
         'video_show': TEXT,
         'volume': INT32,
         'year': UINT16}

ID3V2OPTS = {2: {'frame': Struct('3s3s0s'),
                 'syncsafe': False,
                 'tags': {'COM': '_comment',
                          'PIC': '_image',
                          'RVA': 'volume',
                          'TAL': 'album',
                          'TBP': 'bpm',
                          'TCM': 'composer',
                          'TCO': 'genre',
                          'TCP': 'compilation',
                          'TEN': 'encoder',
                          'TP1': 'artist',
                          'TP2': 'album_artist',
                          'TPA': 'disk',
                          'TRK': 'track',
                          'TS2': 'sort_album_artist',
                          'TSA': 'sort_album',
                          'TSC': 'sort_composer',
                          'TSP': 'sort_artist',
                          'TST': 'sort_name',
                          'TT1': 'grouping',
                          'TT2': 'name',
                          'TT3': 'video_description',
                          'TYE': 'year',
                          'ULT': '_lyrics'}},
             3: {'frame': Struct('4s4s2s'),
                 'syncsafe': False,
                 'tags': {'APIC': '_image',
                          'COMM': '_comment',
                          'RVAD': 'volume',
                          'TALB': 'album',
                          'TBPM': 'bpm',
                          'TCMP': 'compilation',
                          'TCOM': 'composer',
                          'TCON': 'genre',
                          'TENC': 'encoder',
                          'TIT1': 'grouping',
                          'TIT2': 'name',
                          'TIT3': 'video_description',
                          'TPE1': 'artist',
                          'TPE2': 'album_artist',
                          'TPOS': 'disk',
                          'TRCK': 'track',
                          'TSO2': 'sort_album_artist',
                          'TSOC': 'sort_composer',
                          'TYER': 'year',
                          'USLT': '_lyrics'}},
             4: {'frame': Struct('4s4s2s'),
                 'syncsafe': True,
                 'tags': {'APIC': '_image',
                          'COMM': '_comment',
                          'RVA2': 'volume',
                          'TALB': 'album',
                          'TBPM': 'bpm',
                          'TCMP': 'compilation',
                          'TCOM': 'composer',
                          'TCON': 'genre',
                          'TDRC': 'year',
                          'TENC': 'encoder',
                          'TIT1': 'grouping',
                          'TIT2': 'name',
                          'TIT3': 'video_description',
                          'TPE1': 'artist',
                          'TPE2': 'album_artist',
                          'TPOS': 'disk',
                          'TRCK': 'track',
                          'TSO2': 'sort_album_artist',
                          'TSOA': 'sort_album',
                          'TSOC': 'sort_composer',
                          'TSOP': 'sort_artist',
                          'TSOT': 'sort_name',
                          'USLT': '_lyrics'}}}

ID3V2TAGS = dict((tag, attr) for opts in ID3V2OPTS.itervalues()
                 for tag, attr in opts['tags'].iteritems())

ID3V1FIELDS = ['name', 'artist', 'album', 'year', 'comment', 'track', 'genre']

ENCODINGS = {'\x00': {'encoding': 'latin-1', 'term': '\x00'},
             '\x01': {'encoding': 'utf-16', 'term': '\x00\x00'},
             '\x02': {'encoding': 'utf-16-be', 'term': '\x00\x00'},
             '\x03': {'encoding': 'utf-8', 'term': '\x00'},
             '\xff': {'encoding': 'ascii', 'term': '\x00'}}

BOOLS = {True: ('yes', 'true', 'on', '\x01', '1', 'y', 't'),
         False: ('no', 'false', 'off', '\x00', '0', 'n', 'f', '')}

BITRATES = [
    [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0],
    [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0],
    [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
    [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0],
    [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0]]

SRATES = [[11025, 12000, 8000, 0], [0, 0, 0, 0],
          [22050, 24000, 16000, 0], [44100, 48000, 32000, 0]]

GENRES = ['Blues', 'Classic Rock', 'Country', 'Dance', 'Disco', 'Funk',
          'Grunge', 'Hip-Hop', 'Jazz', 'Metal', 'New Age', 'Oldies', 'Other',
          'Pop', 'R&B', 'Rap', 'Reggae', 'Rock', 'Techno', 'Industrial',
          'Alternative', 'Ska', 'Death Metal', 'Pranks', 'Soundtrack',
          'Euro-Techno', 'Ambient', 'Trip-Hop', 'Vocal', 'Jazz+Funk', 'Fusion',
          'Trance', 'Classical', 'Instrumental', 'Acid', 'House', 'Game',
          'Sound Clip', 'Gospel', 'Noise', 'Alternative Rock', 'Bass', 'Soul',
          'Punk', 'Space', 'Meditative', 'Instrumental Pop',
          'Instrumental Rock', 'Ethnic', 'Gothic', 'Darkwave',
          'Techno-Industrial', 'Electronic', 'Pop-Folk', 'Eurodance', 'Dream',
          'Southern Rock', 'Comedy', 'Cult', 'Gangsta', 'Top 40',
          'Christian Rap', 'Pop/Funk', 'Jungle', 'Native US', 'Cabaret',
          'New Wave', 'Psychadelic', 'Rave', 'Showtunes', 'Trailer', 'Lo-Fi',
          'Tribal', 'Acid Punk', 'Acid Jazz', 'Polka', 'Retro', 'Musical',
          'Rock & Roll', 'Hard Rock', 'Folk', 'Folk-Rock', 'National Folk',
          'Swing', 'Fast Fusion', 'Bebob', 'Latin', 'Revival', 'Celtic',
          'Bluegrass', 'Avantgarde', 'Gothic Rock', 'Progressive Rock',
          'Psychedelic Rock', 'Symphonic Rock', 'Slow Rock', 'Big Band',
          'Chorus', 'Easy Listening', 'Acoustic', 'Humour', 'Speech',
          'Chanson', 'Opera', 'Chamber Music', 'Sonata', 'Symphony',
          'Booty Bass', 'Primus', 'Porn Groove', 'Satire', 'Slow Jam', 'Club',
          'Tango', 'Samba', 'Folklore', 'Ballad', 'Power Ballad',
          'Rhythmic Soul', 'Freestyle', 'Duet', 'Punk Rock', 'Drum Solo',
          'Acapella', 'Euro-House', 'Dance Hall', 'Goa', 'Drum & Bass',
          'Club - House', 'Hardcore', 'Terror', 'Indie', 'BritPop',
          'Negerpunk', 'Polsk Punk', 'Beat', 'Christian Gangsta Rap',
          'Heavy Metal', 'Black Metal', 'Crossover', 'Contemporary Christian',
          'Christian Rock', 'Merengue', 'Salsa', 'Thrash Metal', 'Anime',
          'JPop', 'Synthpop', None, None, None, None, None, None, None, None,
          None, None, None, None, None, None, None, None, None, None, None,
          None, None, None, None, None, None, None, None, None, None, None,
          None, None, None, None, None, None, None, None, None, None, None,
          None, None, None, None, None, None, None, None, None, None, None,
          None, None, None, None, None, None, None, None, None, None, None,
          None, None, None, None, None, None, None, None, None, None, None,
          None, None, None, None, None, None, None, None, None, None, None,
          None, None, None, None, None, None, None, None, None, None, None,
          None, None, None, None, None, None, None, None, None, None, None,
          None]

ATOM_SKIP = 'ATOM_SKIP'
ATOM_DATA = 'ATOM_DATA'
ATOM_NODE1 = 'ATOM_NODE1'
ATOM_NODE2 = 'ATOM_NODE2'

ATOMS = {'moov': (ATOM_NODE1, None),
         'moov.udta': (ATOM_NODE1, None),
         'moov.udta.meta': (ATOM_NODE2, None),
         'moov.udta.meta.ilst': (ATOM_NODE1, None),
         'moov.udta.meta.ilst.aART': (ATOM_DATA, 'album_artist'),
         'moov.udta.meta.ilst.covr': (ATOM_DATA, 'image'),
         'moov.udta.meta.ilst.cpil': (ATOM_DATA, 'compilation'),
         'moov.udta.meta.ilst.desc': (ATOM_DATA, 'video_description'),
         'moov.udta.meta.ilst.disk': (ATOM_DATA, 'disk'),
         'moov.udta.meta.ilst.gnre': (ATOM_DATA, 'genre'),
         'moov.udta.meta.ilst.pgap': (ATOM_DATA, 'gapless'),
         'moov.udta.meta.ilst.soaa': (ATOM_DATA, 'sort_album_artist'),
         'moov.udta.meta.ilst.soal': (ATOM_DATA, 'sort_album'),
         'moov.udta.meta.ilst.soar': (ATOM_DATA, 'sort_artist'),
         'moov.udta.meta.ilst.soco': (ATOM_DATA, 'sort_composer'),
         'moov.udta.meta.ilst.sonm': (ATOM_DATA, 'sort_name'),
         'moov.udta.meta.ilst.sosn': (ATOM_DATA, 'sort_video_show'),
         'moov.udta.meta.ilst.tmpo': (ATOM_DATA, 'bpm'),
         'moov.udta.meta.ilst.trkn': (ATOM_DATA, 'track'),
         'moov.udta.meta.ilst.tven': (ATOM_DATA, 'video_episode_id'),
         'moov.udta.meta.ilst.tves': (ATOM_DATA, 'video_episode'),
         'moov.udta.meta.ilst.tvsh': (ATOM_DATA, 'video_show'),
         'moov.udta.meta.ilst.tvsn': (ATOM_DATA, 'video_season'),
         'moov.udta.meta.ilst.\xa9ART': (ATOM_DATA, 'artist'),
         'moov.udta.meta.ilst.\xa9alb': (ATOM_DATA, 'album'),
         'moov.udta.meta.ilst.\xa9cmt': (ATOM_DATA, 'comment'),
         'moov.udta.meta.ilst.\xa9day': (ATOM_DATA, 'year'),
         'moov.udta.meta.ilst.\xa9gen': (ATOM_DATA, 'genre'),
         'moov.udta.meta.ilst.\xa9grp': (ATOM_DATA, 'grouping'),
         'moov.udta.meta.ilst.\xa9lyr': (ATOM_DATA, 'lyrics'),
         'moov.udta.meta.ilst.\xa9nam': (ATOM_DATA, 'name'),
         'moov.udta.meta.ilst.\xa9too': (ATOM_DATA, 'encoder'),
         'moov.udta.meta.ilst.\xa9wrt': (ATOM_DATA, 'composer')}

VORBIS = {'album': 'album',
          'album artist': 'album_artist',
          'album_artist': 'album_artist',
          'albumartist': 'album_artist',
          'artist': 'artist',
          'beats per minute': 'bpm',
          'beats_per_minute': 'bpm',
          'beatsperminute': 'bpm',
          'bpm': 'bpm',
          'comment': 'comment',
          'comments': 'comment',
          'compilation': 'compilation',
          'composer': 'composer',
          'date': 'year',
          'date recorded': 'year',
          'date_recorded': 'year',
          'daterecorded': 'year',
          'disc': 'disk',
          'disc number': 'disk',
          'disc_number': 'disk',
          'discnumber': 'disk',
          'disk': 'disk',
          'disk number': 'disk',
          'disk_number': 'disk',
          'disknumber': 'disk',
          'encoder': 'encoder',
          'gapless': 'gapless',
          'gapless playback': 'gapless',
          'gapless_playback': 'gapless',
          'gaplessplayback': 'gapless',
          'genre': 'genre',
          'grouping': 'grouping',
          'lyrics': 'lyrics',
          'name': 'name',
          'recorded': 'year',
          'sort album': 'sort_album',
          'sort album artist': 'sort_album_artist',
          'sort artist': 'sort_artist',
          'sort composer': 'sort_composer',
          'sort name': 'sort_name',
          'sort video show': 'sort_video_show',
          'sort_album': 'sort_album',
          'sort_album_artist': 'sort_album_artist',
          'sort_artist': 'sort_artist',
          'sort_composer': 'sort_composer',
          'sort_name': 'sort_name',
          'sort_video_show': 'sort_video_show',
          'sortalbum': 'sort_album',
          'sortalbumartist': 'sort_album_artist',
          'sortartist': 'sort_artist',
          'sortcomposer': 'sort_composer',
          'sortname': 'sort_name',
          'sortvideoshow': 'sort_video_show',
          'tempo': 'bpm',
          'title': 'name',
          'track': 'track',
          'track number': 'track',
          'track_number': 'track',
          'tracknumber': 'track',
          'video description': 'video_description',
          'video episode': 'video_episode',
          'video episode id': 'video_episode_id',
          'video season': 'video_season',
          'video show': 'video_show',
          'video_description': 'video_description',
          'video_episode': 'video_episode',
          'video_episode_id': 'video_episode_id',
          'video_season': 'video_season',
          'video_show': 'video_show',
          'videodescription': 'video_description',
          'videoepisode': 'video_episode',
          'videoepisodeid': 'video_episode_id',
          'videoseason': 'video_season',
          'videoshow': 'video_show',
          'volume': 'volume',
          'volume adjustment': 'volume',
          'volume_adjustment': 'volume',
          'volumeadjustment': 'volume',
          'year': 'year'}

IFFIDS = {'ANNO': 'comment',
          'AUTH': 'artist',
          'IART': 'artist',
          'ICMT': 'comment',
          'ICRD': 'year',
          'INAM': 'name',
          'NAME': 'name'}

class TaglibError(Exception):

    pass


class ValidationError(TaglibError):

    pass


class DecodeError(TaglibError):

    pass


class EncodeError(TaglibError):

    pass


class InvalidMedia(TaglibError):

    pass


SafeErrors = TaglibError, IOError, OSError, EOFError, StructError


class Container(MutableMapping):

    types = {}

    def __init__(self, *args, **kwargs):
        self.__dict__.update(dict(*args, **kwargs))

    def __getattribute__(self, attr):
        try:
            return super(Container, self).__getattribute__(attr)
        except AttributeError:
            if attr not in self.types:
                raise

    def __setattr__(self, attr, val):
        try:
            try:
                val = self.validate(val, self.types[attr])
            except ValidationError, error:
                raise ValidationError('%s: %s' % (attr, error))
        except KeyError:
            pass
        super(Container, self).__setattr__(attr, val)

    def __delattr__(self, attr):
        try:
            self.validate(None, self.types[attr])
        except KeyError:
            pass
        try:
            super(Container, self).__delattr__(attr)
        except AttributeError:
            if attr not in self.types:
                raise

    def __getitem__(self, key):
        return self.__getattribute__(key)

    def __setitem__(self, key, val):
        self.__setattr__(key, val)

    def __delitem__(self, key):
        self.__delattr__(key)

    def __iter__(self):
        return (attr for attr in sorted(self.types)
                if not attr.startswith('_') and self[attr])

    def __len__(self):
        return sum(1 for attr in self.__iter__())

    def __repr__(self):
        attrs = ', '.join('%s=%r' % item for item in self.iteritems())
        return '<%s object at 0x%x%s%s>' % (
                type(self).__name__, id(self), ': ' if attrs else '', attrs)

    @staticmethod
    def validate(val, type):
        return val


class Metadata(Container):

    types = TYPES

    @property
    def image_sample(self):
        if self.image:
            val = StringIO()
            self.image.save(val, self.image.format)
            val.seek(512)
            return val.getvalue(True), self.image.format, self.image.size

    @classmethod
    def compare(cls, x, y):
        for attr, type in cls.types.iteritems():
            if type == IDICT:
                continue
            if type == IMAGE:
                attr = 'image_sample'
            val1, val2 = x[attr], y[attr]
            if val1 != val2:
                raise ValidationError('%s: %r != %r' % (attr, val1, val2))

    def __eq__(self, other, strict=False):
        if not isinstance(other, Metadata):
            return NotImplemented
        try:
            self.compare(self, other)
        except ValidationError:
            return False
        return True

    def __ne__(self, other):
        val = self.__eq__(other)
        if val is NotImplemented:
            return val
        return not val

    @staticmethod
    def validate(val, type):
        raise ValidationError('read-only')


class Open(object):

    def __init__(self, file, mode='rb', close=True):
        self.file = file
        self.mode = mode
        self.close = close

    def __enter__(self):
        if isinstance(self.file, basestring):
            self.fp = open(self.file, self.mode)
            self.external = False
        elif isinstance(self.file, (int, long)):
            self.fp = os.fdopen(self.file, self.mode)
            self.external = True
        elif hasattr(self.file, 'seek'):
            self.fp = self.file
            self.external = True
        else:
            raise TypeError('file must be a path, descriptor, or open file')
        if self.external:
            self.pos = self.fp.tell()
        return self.fp

    def __exit__(self, *args):
        if self.external:
            self.fp.seek(self.pos, os.SEEK_SET)
        elif self.close:
            self.fp.close()


class Decoder(Metadata):

    format = None
    close = True

    uint16 = Struct('>H')
    int16 = Struct('>h')
    uint32 = Struct('>L')
    uint32le = Struct('<L')
    long = Struct('4B')

    def __init__(self, file):
        with Open(file, 'rb' if self.close else 'rb+', self.close) as fp:
            self.fp = fp
            try:
                self.decode()
            except SafeErrors, error:
                raise InvalidMedia(error)
            self.changed = False

    def __setattr__(self, attr, val):
        super(Decoder, self).__setattr__(attr, val)
        if attr in self.types:
            self.changed = True

    def __delattr__(self, attr):
        super(Decoder, self).__delattr__(attr)
        if attr in self.types:
            self.changed = True

    def decode(self, *args, **kwargs):
        raise DecodeError('not implemented')

    def save(self, *args, **kwargs):
        raise DecodeError('not implemented')

    def dump(self, *args, **kwargs):
        raise DecodeError('not implemented')

    @classmethod
    def validate(cls, val, type):
        if val is not None:
            if type == GENRE:
                if isinstance(val, (int, long)):
                    if val < 0 or val > 0xff:
                        raise ValidationError('genre index out of range')
                    val = GENRES[val]
                type = TEXT
            elif type in (INT32, UINT16, UINT32):
                if isinstance(val, basestring):
                    try:
                        val = int(val)
                    except ValueError, error:
                        raise ValidationError(error)
                elif not isinstance(val, (int, long)):
                    raise ValidationError('must be an integer')
            if type == BOOL:
                if isinstance(val, basestring):
                    val = val.lower()
                    if isinstance(val, unicode):
                        val = val.encode('ascii', 'ignore').strip()
                    for i in True, False:
                        if val in BOOLS[i]:
                            val = i
                            break
                    else:
                        raise ValidationError('unrecognized boolean setting')
                else:
                    val = bool(val)
            elif type in (DICT, IDICT):
                if not isinstance(val, dict):
                    raise ValidationError('must be a dictionary')
            elif type == INT32:
                if val < -0x80000000 or val > 0x7fffffff:
                    raise ValidationError('out of range of int32')
            elif type == UINT32:
                if val < 0 or val > 0xffffffff:
                    raise ValidationError('out of range of uint32')
            elif type == TEXT:
                if val:
                    if not isinstance(val, unicode):
                        if not isinstance(val, str):
                            val = str(val)
                        val = val.decode('ascii', 'ignore')
                    val = cls.unpad(val)
            elif type == UINT16:
                if val < 0 or val > 0xffff:
                    raise ValidationError('out of range for uint16')
            elif type == UINT16X2:
                if isinstance(val, basestring):
                    val = [int(i) if i.isdigit() else 0 for i in val.split('/')]
                elif isinstance(val, (int, long)):
                    val = val, 0
                if isinstance(val, list):
                    val = tuple(val)
                elif not isinstance(val, tuple):
                    raise ValidationError('invalid track specification')
                if len(val) == 1:
                    val = val[0], 0
                elif len(val) != 2:
                    raise ValidationError('invalid track specification')
                if val[0] == -1:
                    val = 0, val[1]
                if val[1] == -1:
                    val = val[0], 0
                if (not isinstance(val[0], (int, long)) or
                    not isinstance(val[1], (int, long)) or
                    val[0] < 0 or val[0] > 0xffff or
                    val[1] < 0 or val[1] > 0xffff):
                    raise ValidationError('invalid track specification')
                if val == (0, 0):
                    val = None
            elif type == IMAGE:
                if not PIL:
                    raise ValidationError('PIL required for image support')
                if not isinstance(val, ImageFile):
                    try:
                        with Open(val, 'rb') as fp:
                            val = Image.open(fp)
                            val.load()
                    except (TypeError, IOError), error:
                        raise ValidationError(error)
            else:
                raise ValidationError('unknown type: %s' % type)
            if not val:
                val = None
        return val

    @staticmethod
    def unpad(val):
        val = list(val)
        while val and val[-1] in ' \x00':
            val.pop()
        return ''.join(val)


class MP3(Decoder):

    format = 'mp3'
    close = False

    tag_re = re.compile('^[A-Z0-9]{3,4}$')
    genre_re = re.compile(r'^\((\d+)\)$')

    id3v1 = Struct('3s30s30s30s4s30sB')
    id3v2 = Struct('>3s3BL')

    fakemp3 = '\xff\xfe\x14\x00' * 16

    def __init__(self, *args, **kwargs):
        self.hasid3v1 = False
        self.hasid3v2 = False
        self.mp3data = False
        self.version = None
        self.tagstart = None
        self.tagend = None
        self.mp3start = None
        super(MP3, self).__init__(*args, **kwargs)

    def decode(self):
        try:
            self.decode_id3v1()
            self.hasid3v1 = True
        except SafeErrors:
            pass
        self.mp3end = self.fp.tell()
        self.fp.seek(0, os.SEEK_SET)
        try:
            self.decode_id3v2()
            self.hasid3v2 = True
        except SafeErrors:
            pass
        self.tagend = self.fp.tell()
        try:
            self.seekmp3()
            self.mp3data = True
        except SafeErrors:
            self.mp3data = False
        self.mp3start = self.fp.tell()
        if not self.hasid3v1 and not self.hasid3v2 and not self.mp3data:
            raise DecodeError('no tags or mp3 data')

    def decode_id3v1(self):
        try:
            self.fp.seek(self.id3v1.size * -1, os.SEEK_END)
            tag = self.id3v1.unpack(self.fp.read(self.id3v1.size))
            if tag[0] != 'TAG':
                raise DecodeError('no id3v1 tag')
            self.fp.seek(self.id3v1.size * -1, os.SEEK_END)
        except SafeErrors:
            self.fp.seek(0, os.SEEK_END)
            raise
        try:
            self.name, self.artist, self.album, self.year = tag[1:5]
        except ValidationError:
            pass
        if tag[5][28] in ' \x00' and tag[5][29] != '\x00':
            self.comment = tag[5][:28]
            self.track = ord(tag[5][29])
        else:
            self.comment = tag[5]
        self.genre = tag[6]

    def decode_id3v2(self):
        pos = self.fp.tell()
        try:
            head = self.id3v2.unpack(self.fp.read(self.id3v2.size))
            if head[0] != 'ID3':
                raise DecodeError('no id3v2 tag')
            if head[1] not in ID3V2OPTS:
                raise DecodeError('unknown version: %d' % head[1])
            if head[2]:
                raise DecodeError('unknown revision: %d' % heead[2])
            if head[3]:
                raise DecodeError('unknown flags: %d' % head[3])
        except SafeErrors:
            self.fp.seek(pos, os.SEEK_SET)
            raise
        self.version = head[1]
        opts = ID3V2OPTS[self.version]
        frame, syncsafe = opts['frame'], opts['syncsafe']
        bytes_left = self.getint(head[4], syncsafe=True)
        while bytes_left > frame.size:
            try:
                tag, size, flags = frame.unpack(self.fp.read(frame.size))
                if not self.tag_re.search(tag):
                    self.fp.seek(frame.size * -1, os.SEEK_CUR)
                    break
                size = self.getint(size, syncsafe)
                val = self.fp.read(size)
                bytes_left -= (frame.size + size)
                if self.getint(flags):
                    raise DecodeError('unknown flags')
                attr = ID3V2TAGS.get(tag)
                if attr:
                    key = None
                    type = self.types[attr]
                else:
                    attr = '_unknown'
                    type = None
                    key = tag
                if type in (BOOL, GENRE, TEXT, UINT16, UINT16X2):
                    val = self.getstr(val)
                if not val:
                    raise DecodeError('empty value')
                if type == DICT:
                    ebyte, val, enc = self.getenc(val)
                    lang = val[:3]
                    key, val = self.split(val[3:], enc['term'])
                    key = self.validate(self.getstr(ebyte + key), TEXT)
                    val = self.validate(self.getstr(ebyte + val), TEXT)
                    if key == GAPLESS:
                        val = self.validate(val, BOOL)
                    key = lang, key
                elif type == GENRE:
                    try:
                        val = int(self.genre_re.search(val).group(1))
                    except AttributeError:
                        pass
                elif type == IDICT:
                    ebyte, val, enc = self.getenc(val)
                    if tag == 'PIC':
                        val = val[3:]
                    else:
                        val = self.split(val)[1]
                    ptype = ord(val[0])
                    key, val = self.split(val[1:], enc['term'])
                    key = (self.validate(self.getstr(ebyte + key), TEXT),)
                    val = self.validate(StringIO(val), IMAGE), ptype
                elif type == INT32:
                    # XXX something not right here
                    if tag == 'RVA2':
                        val = self.int16.unpack(self.split(val)[1][1:3])[0]
                        val *= RVA2FACTOR
                    else:
                        dir, bits, val = ord(val[0]) & 1, ord(val[1]), val[2:]
                        i, r = divmod(bits, 8)
                        if r:
                            i += 1
                        val = self.uint16.unpack(val[:i])[0]
                        if not dir:
                            val *= -1
                if val:
                    if key:
                        self.setdict(attr, key, val)
                    else:
                        self[attr] = val
            except SafeErrors, error:
                pass

    @classmethod
    def getint(cls, val, syncsafe=True):
        if isinstance(val, str):
            val = '\x00' * (cls.uint32.size - len(val)) + val
            val = cls.uint32.unpack(val)[0]
        if syncsafe:
            val = (((val & 0x0000007f) >> 0) | ((val & 0x00007f00) >> 1) |
                   ((val & 0x007f0000) >> 2) | ((val & 0x7f000000) >> 3))
        return val

    @classmethod
    def getstr(cls, val):
        ebyte, val, enc = cls.getenc(val)
        val = cls.split(val, enc['term'], offset=0)[0]
        return val.decode(enc['encoding'], 'ignore').strip()

    @staticmethod
    def mkstr(val, utf16=False):
        if val is None:
            val = u''
        if not utf16:
            try:
                return '\x00%s\x00' % val.encode('latin-1')
            except UnicodeEncodeError:
                pass
        return '\x01\xff\xfe%s\x00\x00' % val.encode('utf-16-le')

    @staticmethod
    def getenc(val):
        try:
            ebyte = val[0]
            enc, val = ENCODINGS[ebyte], val[1:]
        except (KeyError, IndexError):
            ebyte = ''
            enc = ENCODINGS['\xff']
        return ebyte, val, enc

    @staticmethod
    def split(val, term='\x00', offset=1):
        end = len(val)
        tsize = len(term)
        i = 0
        while i < end:
            j = val.find(term, i)
            if j == i:
                break
            if j == -1:
                i = end
            else:
                i = j + j % tsize
        i += offset * tsize
        return val[:i], val[i:]

    def save(self, version=None, keep_unknown=True):
        if not self.changed:
            raise EncodeError('no metadata has changed')
        self.fp.seek(0, os.SEEK_SET)
        self.dump(self.fp, version, keep_unknown, 0, inplace=True)

    def dump(self, file, version=None, keep_unknown=True,
             padding=None, inplace=False):
        if version is None and self.hasid3v2:
            version = self.version
        if version is None:
            version = DEFAULT_ID3_VERSION
        if version not in ID3V2OPTS:
            raise EncodeError('unknown version: %d' % version)
        if keep_unknown and self._unknown and version != self.version:
            raise EncodeError("can't change version and keep unknown tags")
        if padding is None:
            padding = DEFAULT_ID3V2_PADDING
        with Open(file, 'rb+' if inplace else 'wb') as fp:

            id3v1 = id3v2 = False
            for attr in ID3V2OPTS[version]['tags'].itervalues():
                if self[attr]:
                    id3v2 = True
                    if attr in ID3V1FIELDS:
                        id3v1 = True
                        break

            id3v1pos = None
            if inplace:
                if self.hasid3v1:
                    id3v1pos = -128
                elif id3v1:
                    id3v1pos = 0
                    self.hasid3v1 = True
            elif id3v1:
                id3v1pos = 0

            id3v2pos = None
            if inplace:
                if self.hasid3v2:
                    id3v2pos = self.tagstart
                elif id3v2:
                    raise EncodeError('no room for id3v2 tag')
            elif id3v2:
                id3v2pos = 0

            if id3v2pos is not None:
                fp.seek(id3v2pos, os.SEEK_SET)
                self.encode_id3v2(fp, version, keep_unknown, padding, inplace)

            if self.mp3data and not inplace:
                for frame in self.frames:
                    fp.write(frame)

            if id3v1pos is not None:
                fp.seek(id3v1pos, os.SEEK_END)
                self.encode_id3v1(fp)
        return fp

    def encode_id3v1(self, fp):
        if self.track and self.track[0] and self.track[0] < 256:
            comment = '%s\x00%c' % (self.pad(self.comment, 28), self.track[0])
        else:
            comment = self.pad(self.comment)
        if self.genre and self.genre in GENRES:
            genre = GENRES.index(self.genre)
        else:
            genre = 255
        fp.write(self.id3v1.pack('TAG', self.pad(self.name),
                                 self.pad(self.artist), self.pad(self.album),
                                 self.pad(self.year, 4), comment, genre))

    @staticmethod
    def pad(val, size=30):
        if val is None:
            val = ''
        elif isinstance(val, unicode):
            val = val.encode('ascii', 'ignore').strip()[:size]
        else:
            val = str(val)
        return val + '\x00' * (size - len(val))

    def encode_id3v2(self, fp, version, keep_unknown, padding, inplace):
        if inplace:
            if self.mp3data:
                bytes_left = self.mp3start
            else:
                bytes_left = self.tagend - self.tagstart
        else:
            bytes_left = self.getint('\x7f\x7f\x7f\x7f', syncsafe=True)
        if bytes_left < 10:
            raise EncodeError('no room for id3v2 header, try dump()')
        head = fp.tell()
        size = 0
        bytes_left -= 10
        if inplace:
            tag = []
        else:
            fp.write('\x00' * 10)
        for frame in self.id3v2_frames(version, keep_unknown):
            flen = len(frame)
            if flen > bytes_left:
                raise EncodeError('no room for id3v2 tag, try dump()')
            size += flen
            bytes_left -= flen
            if inplace:
                tag.append(frame)
            else:
                fp.write(frame)
        if inplace:
            fp.seek(10, os.SEEK_CUR)
            fp.write(''.join(tag) + '\x00' * bytes_left)
            size += bytes_left
            self.version = version
        elif padding:
            fp.write('\x00' * padding)
            size += padding
        pos = fp.tell()
        try:
            fp.seek(head, os.SEEK_SET)
            size = self.uint32.unpack(self.getbytes(size, syncsafe=True))[0]
            fp.write(self.id3v2.pack('ID3', version, 0, 0, size))
        finally:
            fp.seek(pos, os.SEEK_SET)

    def id3v2_frames(self, version, keep_unknown):
        opts = ID3V2OPTS[version]
        syncsafe, frame = opts['syncsafe'], opts['frame']
        taglen, sizelen, flagslen = [
                len(i) for i in frame.unpack('x' * frame.size)]
        i = 4 - sizelen
        flags = '\x00' * flagslen
        for tag, val in self.id3v2_items(opts['tags'], keep_unknown):
            if len(tag) != taglen:
                raise EncodeError('invalid tag size: %r' % tag)
            yield tag + self.getbytes(len(val), syncsafe)[i:] + flags + val

    def id3v2_items(self, tags, keep_unknown):
        for tag, attr in tags.iteritems():
            val = self[attr]
            if not val:
                continue
            type = self.types[attr]
            if type == BOOL:
                val = u'1'
            elif type == DICT:
                for key, val in val.iteritems():
                    lang, key = key
                    if key == GAPLESS:
                        val = u'1' if val else u'0'
                    key2, val2 = self.mkstr(key), self.mkstr(val)
                    if key2[0] == val2[0]:
                        key, val = key2, val2
                    elif key2[0] == '\x01':
                        key, val = key2, self.mkstr(val, utf16=True)
                    else:
                        key, val = self.mkstr(key, utf16=True), val2
                    yield tag, key[0] + lang + key[1:] + val[1:]
                continue
            elif type == GENRE:
                if val in GENRES:
                    val = u'(%d)' % GENRES.index(val)
            elif type == IDICT:
                for key, val in val.iteritems():
                    val, ptype = val
                    if tag == 'PIC':
                        if val.format == 'JPEG':
                            format = 'JPG'
                        else:
                            format = val.format[:3]
                    else:
                        format = 'image/%s\x00' % val.format.lower()
                    key = self.mkstr(key[0])
                    ebyte, key = key[0], key[1:]
                    data = StringIO()
                    val.save(data, val.format)
                    data = data.getvalue()
                    yield tag, ebyte + format + chr(ptype) + key + data
                continue
            elif type == INT32:
                if tag == 'RVA2':
                    val = self.int16.pack(int(val / RVA2FACTOR))
                    val = '\x00\x01' + val + '\x00'
                else:
                    if val < 0:
                        val *= -1
                        dir = '\x00'
                    else:
                        dir = '\x03'
                    val = list(self.uint32.pack(val))
                    while val and val[0] == '\x00':
                        val.pop(0)
                    val = ''.join(val)
                    i = len(val)
                    val = dir + chr(i * 8) + val * 2 + '\x00' * i * 2
            elif type == UINT16:
                val = u'%d' % val
            elif type == UINT16X2:
                val = u'%d/%d' % val
            if isinstance(val, unicode):
                val = self.mkstr(val)
            yield tag, val
        if self._unknown and keep_unknown:
            for tag, val in self._unknown.iteritems():
                yield tag, val

    @classmethod
    def getbytes(cls, val, syncsafe=False):
        val = cls.uint32.pack(val)
        if syncsafe:
            val = cls.long.unpack(val)
            val = cls.long.pack(((val[1] >> 5) & 0x07) | (val[0] << 3) & 0x7f,
                                ((val[2] >> 6) & 0x03) | (val[1] << 2) & 0x7f,
                                ((val[3] >> 7) & 0x01) | (val[2] << 1) & 0x7f,
                                ((val[3] >> 0) & 0x7f))
        return val

    def get_comment(self, lang='eng', key=None):
        if key != ANY:
            key = lang, key
        return self.getdict('_comment', key)

    def set_comment(self, val, lang='eng', key=None):
        if key == GAPLESS:
            val = self.validate(val, BOOL)
        else:
            val = self.validate(val, TEXT)
        self.setdict('_comment', (lang, key), val)

    def del_comment(self, lang='eng', key=None):
        if key != ANY:
            key = lang, key
        self.deldict('_comment', key)

    comment = property(get_comment, set_comment, del_comment)

    def get_gapless(self):
        return self.get_comment(key=GAPLESS)

    def set_gapless(self, val):
        self.set_comment(val, key=GAPLESS)

    def del_gapless(self):
        self.del_comment(key=GAPLESS)

    gapless = property(get_gapless, set_gapless, del_gapless)

    def get_lyrics(self, lang='eng', key=None):
        if key != ANY:
            key = lang, key
        return self.getdict('_lyrics', key)

    def set_lyrics(self, val, lang='eng', key=None):
        val = self.validate(val, TEXT)
        self.setdict('_lyrics', (lang, key), val)

    def del_lyrics(self, lang='eng', key=None):
        if key != ANY:
            key = lang, key
        self.deldict('_lyrics', key)

    lyrics = property(get_lyrics, set_lyrics, del_lyrics)

    def get_image(self, key=ANY):
        if key != ANY:
            key = (key,)
        val = self.getdict('_image', key)
        if val:
            return val[0]

    def set_image(self, val, key=None, ptype=3):
        val = self.validate(val, IMAGE)
        self.setdict('_image', (key,), (val, ptype))

    def del_image(self, key=ANY):
        if key != ANY:
            key = (key,)
        self.deldict('_image', key)

    image = property(get_image, set_image, del_image)

    def getdict(self, attr, key):
        if self[attr] and key == ANY:
            key = sorted(self[attr].keys())[0]
        try:
            return self[attr][key]
        except (TypeError, KeyError):
            pass

    def setdict(self, attr, key, val):
        if val is None:
            self.deldict(attr, key)
        else:
            try:
                self[attr][key] = val
            except (TypeError, KeyError):
                self[attr] = {key: val}

    def deldict(self, attr, key):
        try:
            dict = self[attr]
            if dict and key == ANY:
                key = sorted(self[attr].keys())[0]
            del dict[key]
            if not dict:
                del self[attr]
        except (TypeError, KeyError):
            pass

    def seekmp3(self, offset=0, samplesize=None):
        if samplesize is None:
            samplesize = SAMPLESIZE
        pos = self.fp.tell()
        try:
            sample = self.fp.read(samplesize)
            while True:
                offset = sample.find('\xff', offset)
                if offset == -1:
                    raise DecodeError('no syncword found')
                try:
                    flen = self.framelen(sample[offset:offset + 4])
                    next = offset + flen
                    self.framelen(sample[next:next + 4])
                    pos += offset
                    return flen
                except DecodeError:
                    pass
                offset += 1
        finally:
            self.fp.seek(pos, os.SEEK_SET)

    @property
    def frames(self):
        pos = self.fp.tell()
        try:
            self.fp.seek(self.mp3start, os.SEEK_SET)
            while True:
                head = self.fp.read(4)
                try:
                    yield head + self.fp.read(self.framelen(head) - 4)
                except SafeErrors:
                    break
        finally:
            self.fp.seek(pos, os.SEEK_SET)

    @classmethod
    def framelen(cls, val):
        val = cls.uint32.unpack(val)[0]
        if (val >> 21) & 2047 != 2047:
            raise DecodeError('not an mp3 header')
        version = (val >> 19) & 3
        layer = (val >> 17) & 3
        key = None
        if version == 3:
            if layer == 3:
                key = 0
            elif layer == 2:
                key = 1
            elif layer == 1:
                key = 2
        elif version in (0, 2):
            if layer == 3:
                key = 3
            elif layer in (1, 2):
                key = 4
        if key is None:
            raise DecodeError('invalid version/layer')
        bitrate = BITRATES[key][(val >> 12) & 15] * 1000
        if not bitrate:
            raise DecodeError('invalid bitrate')
        srate = SRATES[version][(val >> 10) & 3]
        if not srate:
            raise DecodeError('invalid sample rate')
        padding = (val >> 9) & 1
        if layer == 3:
            return int((12 * bitrate / srate + padding) * 4)
        return int(144 * bitrate / srate + padding)


class IFF(MP3):

    format = 'iff'

    def decode(self, pos=None, end=None, fmt=None):
        if pos is None:
            pos = self.fp.tell()
        if end is None:
            self.fp.seek(0, os.SEEK_END)
            end = self.fp.tell()
        try:
            self.decode_id3v1()
            self.hasid3v1 = True
        except SafeErrors:
            pass
        while pos < end:
            self.fp.seek(pos, os.SEEK_SET)
            tag = self.fp.read(4)
            if fmt is None:
                if tag in ('FORM', 'LIST', 'CAT'):
                    fmt = self.uint32
                elif tag == 'RIFF':
                    fmt = self.uint32le
                else:
                    raise DecodeError('not an IFF file')
            size = fmt.unpack(self.fp.read(fmt.size))[0]
            pos += 8
            if tag in ('FORM', 'LIST', 'CAT', 'RIFF'):
                self.decode(pos + 4, pos + size, fmt)
            elif tag in IFFIDS:
                attr = IFFIDS[tag]
                val = self.unpad(self.fp.read(size).decode('utf-8', 'ignore'))
                try:
                    self[attr] = val
                except ValidationError:
                    pass
            elif tag == 'ID3 ':
                try:
                    self.decode_id3v2()
                    self.tagstart = pos
                    self.tagend = pos + size
                    self.hasid3v2 = True
                except SafeErrors:
                    pass
            elif tag == 'data':
                try:
                    self.seekmp3()
                    self.mp3data = True
                    self.mp3start = self.fp.tell()
                except SafeErrors:
                    pass
            if not size:
                break
            pos += size + size % 2


class M4A(Decoder):

    format = 'm4a'
    m4a = Struct('>L4s')
    uint16x2 = Struct('>2H')

    def decode(self, pos=None, end=None, base=None, ftyp=False):
        if pos is None:
            pos = self.fp.tell()
        if end is None:
            self.fp.seek(0, os.SEEK_END)
            end = self.fp.tell()
        if base is None:
            base = []
        while pos < end:
            self.fp.seek(pos, os.SEEK_SET)
            size, name = self.m4a.unpack(self.fp.read(8))
            path = base + [name]
            atom = '.'.join(path)
            if not ftyp:
                if atom != 'ftyp':
                    raise DecodeError('not an mpeg4 file')
                ftyp = True
            atype, attr = ATOMS.get(atom, (None, None))
            if atype == ATOM_NODE1:
                self.decode(pos + 8, pos + size, path, ftyp)
            elif atype == ATOM_NODE2:
                self.decode(pos + 12, pos + size, path, ftyp)
            elif atype == ATOM_DATA:
                self.fp.seek(pos + 24, os.SEEK_SET)
                val = self.fp.read(size - 24)
                type = self.types[attr]
                if type == GENRE:
                    if len(val) == 2 and val[0] in '\x00\x01':
                        val = self.uint16.unpack(val)[0] - 1
                elif type == IMAGE:
                    val = StringIO(val)
                elif type == TEXT:
                    val = val.decode('utf-8', 'ignore')
                elif type == UINT16:
                    if attr == 'year':
                        val = val[:4]
                    else:
                        val = self.uint16.unpack(val)[0]
                elif type == UINT16X2:
                    val = self.uint16x2.unpack(val[2:6])
                elif type == UINT32:
                    val = self.uint32.unpack(val)[0]
                try:
                    self[attr] = val
                except ValidationError:
                    pass
            if not size:
                break
            pos += size


class Vorbis(Decoder):

    def decode(self):
        self.encoder = self.getstr()
        for i in xrange(self.getint()):
            tag, val = self.getstr().split(u'=', 1)
            attr = VORBIS.get(tag.lower())
            if attr:
                try:
                    self[attr] = val
                except ValidationError:
                    pass

    def getint(self):
        return self.uint32le.unpack(self.fp.read(4))[0]

    def getstr(self):
        return self.fp.read(self.getint()).decode('utf-8', 'ignore')


class FLAC(Vorbis):

    format = 'flac'
    flac = Struct('B3s')

    def decode(self):
        if self.fp.read(4) != 'fLaC':
            raise DecodeError('no flac marker found')
        pos = self.fp.tell()
        self.fp.seek(0, os.SEEK_END)
        end = self.fp.tell()
        while pos < end:
            self.fp.seek(pos, os.SEEK_SET)
            head, size = self.flac.unpack(self.fp.read(4))
            pos += 4
            size = self.uint32.unpack('\x00' + size)[0]
            if head & 127 == 4:
                super(FLAC, self).decode()
            if not size or head & 128:
                break
            pos += size


class OGG(Vorbis):

    format = 'ogg'

    ogg = Struct('>4sBBQLLLB')

    def decode(self):
        pos = self.fp.tell()
        self.fp.seek(0, os.SEEK_END)
        end = self.fp.tell()
        while pos < end:
            self.fp.seek(pos, os.SEEK_SET)
            head = self.ogg.unpack(self.fp.read(self.ogg.size))
            if head[0] != 'OggS':
                raise DecodeError('not an OGG file')
            pos += (sum(ord(i) for i in self.fp.read(head[7])) +
                    self.ogg.size + head[7])
            if self.fp.read(7) == '\x03vorbis':
                super(OGG, self).decode()


DECODERS = FLAC, OGG, M4A, IFF, MP3


def tagopen(file, readonly=True):
    for cls in DECODERS:
        try:
            tag = cls(file)
            if readonly:
                tag = Metadata(tag)
            return tag
        except InvalidMedia:
            pass
    raise InvalidMedia('no suitable decoder')


def dump(val, name=None, rval=False):
    if isinstance(val, basestring) and not name:
        name = val
        val = globals()[val]
    from pprint import pformat
    if rval:
        if isinstance(val, dict):
            new = {}
            for x, y in val.iteritems():
                new[x] = raw(y)
        else:
            new = []
            for x in val:
                new.append(raw(x))
        val = new
    items = pformat(val).splitlines()
    lead = '%s = ' % name
    isize = len(lead)
    indent = ' ' * isize
    lines = [[lead]]
    last = len(items) - 1
    for i, line in enumerate(items):
        lines[-1].append(line)
        if i != last:
            lines.append([indent])
    print '\n'.join(''.join(line) for line in lines)


def branch(val, rval=0, indent=0, name=None):
    indent = ' ' * indent
    next = indent + ' ' * 4
    for i, item in enumerate(sorted(set(val))):
        if rval:
            item = raw(item)
        print '%s%sif %s == %r:' % (indent, 'el' if i else '', name, item)
        print '%s# example' % next
        print '%spass' % next
        if not i:
            print "%sprint ' ' * %d + '# ' + repr(val)" % (next, len(next))


def walk(dir, ext=None):
    log.info('scanning %s', dir)
    if ext:
        ext = '.' + ext.lower()
    for basedir, subdirs, filenames in os.walk(dir):
        if '.svn' in subdirs:
            subdirs.remove('.svn')
        for filename in filenames:
            if ext and os.path.splitext(filename)[1].lower() != ext:
                continue
            yield os.path.join(basedir, filename)
    log.info('finished scanning %s', dir)


def test(file):
    src = MP3(file)
    Metadata.compare(src, MP3(src.dump(StringIO())))


class raw(object):

    def __init__(self, val):
        self.val = val

    def __repr__(self):
        return str(self.val)


import logging as log
import time

log.basicConfig(level=log.INFO, stream=sys.stderr,
                format='%(levelname)s> %(message)s')

class Timer(object):

    def __init__(self, name):
        self.name = name

    def __enter__(self):
        self.start = time.time()
        log.info('started job: %s', self.name)
        return self

    def __exit__(self, *args):
        elapsed = self.clock(time.time() - self.start)
        log.info('%s finished in %s', self.name, elapsed)

    @staticmethod
    def clock(seconds):
        minutes, seconds = divmod(int(seconds), 60)
        hours, minutes = divmod(minutes, 60)
        return ':'.join(str(item).zfill(2)
                        for item in (hours, minutes, seconds))


class Meter(Timer):

    FREQ = 0.25
    WIDTH = 78
    ANSI_HIDE_CURSOR = '\x1b[?25l'
    ANSI_SHOW_CURSOR = '\x1b[?25h'

    status_fmt = '[%d/%d] %s: Elapsed: %s | Remaining: %s'

    def __init__(self, items, name, freq=None, width=None, stream=None):
        if not hasattr(items, '__len__'):
            items = list(items)
        if freq is None:
            freq = self.FREQ
        if width is None:
            width = self.WIDTH
        if stream is None:
            stream = sys.stderr
        self.items = items
        self.size = len(items)
        self.freq = freq
        self.width = width
        self.stream = stream
        super(Meter, self).__init__(name)

    def write(self, line):
        self.stream.write(line.ljust(self.width) + '\r')

    def __enter__(self):
        self.last = 0
        self.write(self.ANSI_HIDE_CURSOR)
        return super(Meter, self).__enter__()

    def __exit__(self, *args):
        self.write(self.ANSI_SHOW_CURSOR)
        return super(Meter, self).__exit__(*args)

    def update(self, force=True):
        now = time.time()
        if force or now - self.last >= self.freq:
            self.last = now
            done = now - self.start
            if self.pos:
                left = self.clock(done / self.pos * (self.size - self.pos))
            else:
                left = '--:--:--'
            self.write(self.status_fmt % (
                self.pos, self.size, self.name, self.clock(done), left))

    def __iter__(self):
        for i, item in enumerate(self.items):
            self.pos = i
            self.update()
            yield item

    def __getattribute__(self, attr):
        try:
            return super(Meter, self).__getattribute__(attr)
        except AttributeError, error:
            tb = sys.exc_traceback
            try:
                level = log._levelNames[attr.upper()]
                def inner(*args, **kwargs):
                    if level >= log.root.level:
                        self.write('')
                        log.log(level, *args, **kwargs)
                        self.update(force=True)
                return inner
            except KeyError:
                raise error, None, tb


def main():
    return 0

if __name__ == '__main__':
    sys.exit(main())
